--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 5764f8a060a65287966d5be310faef5eecd1d207
Parents : 495de05
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-02T16:27:04-05:00
feat(sri): implement SRI verification for external scripts and add integrity.json files for Codec2 and RNode Flasher
Changes
6 files changed, 322 insertions(+), 13 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index 6c5d2b06..ec608a4d 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -372,15 +372,58 @@ export default {
}
this.refreshCapabilities();
},
- _loadScript(src) {
+ async _loadScript(src) {
+ // Fetch and verify SRI before injecting
+ const integrity = this._rnodeIntegrity || (await this._loadRnodeIntegrity());
+ const pathParts = src.split("/");
+ const filename = pathParts.slice(-2).join("/"); // e.g., "zip.min.js" or "crypto-js@3.9.1-1/core.js"
+ const expectedHash = integrity?.[filename];
+
+ if (!expectedHash) {
+ throw new Error(`RNode: SRI hash missing for ${filename}. Refusing to load untrusted code.`);
+ }
+
+ const res = await fetch(src);
+ if (!res.ok) {
+ throw new Error(`RNode: failed to fetch ${src} (${res.status})`);
+ }
+ const buf = await res.arrayBuffer();
+ const hash = await crypto.subtle.digest("SHA-384", buf);
+ const actualHash = "sha384-" + btoa(String.fromCharCode(...new Uint8Array(hash)));
+ if (actualHash !== expectedHash) {
+ throw new Error(`RNode: SRI hash mismatch for ${filename}. Possible tampering detected.`);
+ }
+
+ // Inject verified content as blob
+ const blob = new Blob([buf], { type: "application/javascript" });
+ const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
- script.src = src;
- script.onload = resolve;
- script.onerror = reject;
+ script.src = blobUrl;
+ script.onload = () => {
+ URL.revokeObjectURL(blobUrl);
+ resolve();
+ };
+ script.onerror = () => {
+ URL.revokeObjectURL(blobUrl);
+ reject(new Error(`Failed to load ${src}`));
+ };
document.head.appendChild(script);
});
},
+ async _loadRnodeIntegrity() {
+ if (this._rnodeIntegrity) return this._rnodeIntegrity;
+ try {
+ const res = await fetch("/rnode-flasher/js/integrity.json");
+ if (!res.ok) throw new Error("Failed to load integrity.json");
+ const data = await res.json();
+ this._rnodeIntegrity = data.files || {};
+ return this._rnodeIntegrity;
+ } catch (e) {
+ console.error("RNode: Failed to load integrity hashes:", e);
+ throw e;
+ }
+ },
async _openTransport() {
if (this.connectionMethod === TRANSPORT_SERIAL) {
const transport = await SerialTransport.request();
diff --git a/meshchatx/src/frontend/js/Codec2Loader.js b/meshchatx/src/frontend/js/Codec2Loader.js
index f29e5db8..4e181af4 100644
--- a/meshchatx/src/frontend/js/Codec2Loader.js
+++ b/meshchatx/src/frontend/js/Codec2Loader.js
@@ -9,8 +9,41 @@ const codec2ScriptPaths = [
let loadPromise = null;
let resolvedOk = false;
+let integrityHashes = null;
-function injectScript(src) {
+/** Computes SHA-384 hash of ArrayBuffer for SRI verification. */
+async function computeSriHash(buf) {
+ const hash = await crypto.subtle.digest("SHA-384", buf);
+ const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
+ return `sha384-${base64}`;
+}
+
+/** Loads integrity.json. Returns null if not available. */
+async function loadIntegrityHashes() {
+ if (integrityHashes !== null) return integrityHashes;
+ try {
+ const res = await fetch("/assets/js/codec2-emscripten/integrity.json");
+ if (!res.ok) return null;
+ const data = await res.json();
+ integrityHashes = data.files || {};
+ return integrityHashes;
+ } catch {
+ return null;
+ }
+}
+
+/** Verifies SRI hash. Throws if mismatch or missing when required. */
+async function verifySri(buf, expectedHash, name) {
+ if (!expectedHash) {
+ throw new Error(`Codec2: SRI hash missing for ${name}. Refusing to load untrusted code.`);
+ }
+ const actualHash = await computeSriHash(buf);
+ if (actualHash !== expectedHash) {
+ throw new Error(`Codec2: SRI hash mismatch for ${name}. Possible tampering detected.`);
+ }
+}
+
+async function injectScript(src) {
if (typeof document === "undefined") {
return Promise.resolve();
}
@@ -29,16 +62,34 @@ function injectScript(src) {
});
}
+ // Fetch, verify SRI, then inject as blob
+ const integrity = await loadIntegrityHashes();
+ const filename = src.split("/").pop();
+ const expectedHash = integrity?.[filename];
+
+ const res = await fetch(src);
+ if (!res.ok) {
+ throw new Error(`Codec2: failed to fetch ${src} (${res.status})`);
+ }
+ const buf = await res.arrayBuffer();
+ await verifySri(buf, expectedHash, filename);
+
+ // Create blob URL for verified content
+ const blob = new Blob([buf], { type: "application/javascript" });
+ const blobUrl = URL.createObjectURL(blob);
+
return new Promise((resolve, reject) => {
const script = document.createElement("script");
- script.src = src;
+ script.src = blobUrl;
script.async = false;
script.setAttribute(attrName, src);
script.addEventListener("load", () => {
+ URL.revokeObjectURL(blobUrl);
script.setAttribute(loadedAttr, "true");
resolve();
});
script.addEventListener("error", () => {
+ URL.revokeObjectURL(blobUrl);
script.remove();
reject(new Error(`Failed to load ${src}`));
});
diff --git a/meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json b/meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json
new file mode 100644
index 00000000..0b17bb09
--- /dev/null
+++ b/meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json
@@ -0,0 +1,15 @@
+{
+ "version": "local",
+ "files": {
+ "c2dec.wasm": "sha384-4zOZs7/jXc1loaji7m+4oob/zkOWTL1sDJMI1YxbBpxYaiMipiF3lwYiAvmWCeir",
+ "c2dec.js": "sha384-rHKNP761F8BDKgj24VSzJO/rdm9usDJKoHjdIOKt83hS4IcZKJau6XDgZXuiZCA8",
+ "c2enc.wasm": "sha384-1EVNLUcps00Ihe9cR40JaZd73jnJRtQjS9e6zUVOd4tni7/BwcwV9oouj/CPgA8N",
+ "c2enc.js": "sha384-Rrnbr/tbCaDiL1n14+MccgIw0Cef+yrpbdYythu5/PkpzZhsVc+9Fle0WyT+7AWc",
+ "sox.wasm": "sha384-NMa5LHokdsVBdaUPlK1jnsSQ1nmgAnf8POPAwFpRkfjckdBCUSs1RhRd03CHwDAN",
+ "sox.js": "sha384-iSAIvLWRyVYwMrKhVzrmvcCzS5noiipjTxOowhXWkmDW0xFGjLV8YGpppX1qRdum",
+ "codec2-lib.js": "sha384-ts/lRDKjHaqD4pUZeyXI+nMIe75khxJEcORRLiqaJo496FFa1FSkfKzW2lm9RAus",
+ "codec2-microphone-recorder.js": "sha384-dGQR+cfKn/lejCt32+N8EJw94+VwFvTeFILKDo1BOm0R+1qElYCR+jrPHezOEzb9",
+ "processor.js": "sha384-m1JmxQg3+KdNiu25A6ArCGFaaKmN6MzONe+LaphWLPfwXqs8bxD6dWNbehUUG54Y",
+ "wav-encoder.js": "sha384-QToS0oLeqMQo+9wYKkx+pTNlBRZsRKsKplmJB/Plf3LwngxNi+0YFJBkb17aCNIM"
+ }
+}
diff --git a/meshchatx/src/frontend/public/rnode-flasher/js/integrity.json b/meshchatx/src/frontend/public/rnode-flasher/js/integrity.json
new file mode 100644
index 00000000..25b485b6
--- /dev/null
+++ b/meshchatx/src/frontend/public/rnode-flasher/js/integrity.json
@@ -0,0 +1,12 @@
+{
+ "version": "local",
+ "files": {
+ "zip.min.js": "sha384-cmZdkDGSIsPVhqU0ukL2jrqMz+w8htEprOs0cRQQqpOyXa+KQaFMYjioMb10W9IU",
+ "crypto-js@3.9.1-1/core.js": "sha384-jol9z4ZPGPDmUbsnDIVZFMN/LePaZZosij//98kSSqhMfSW8492HUOcStDqmJgQb",
+ "crypto-js@3.9.1-1/md5.js": "sha384-dkDpdL8rWfdl9pDjG0LmtTQhCkkzhhwt/32QdshNFJzIv3bFKcGg1P9O3bmj3e06",
+ "esptool-js@0.4.5/bundle.js": "sha384-oSX6xrk2dnf+rpEk51E6dJPAZnQaIGGyXqpUMTOI+/zy4EQTPcWAyd7GKuRu7dhu",
+ "nrf52_dfu_flasher.js": "sha384-w+piNnfdIh7UW5SxnYqwZoasIVB+45f2ArqYEMryVvJH2ZwmlqF0duPmYQEjQnxy",
+ "rnode.js": "sha384-iagMdctE36S8/4PC8Im2LMu/iQGMc/6jiDOJOM8bn1ILYwXFHoMyS13eeLwFl/Ft",
+ "web-serial-polyfill@1.0.15/dist/serial.js": "sha384-5/ferViMHB357beLZsaH2fUuLpRZlFJTWZBVyF/ZLHEzNOsQofokUthz/nB+T11x"
+ }
+}
diff --git a/tests/frontend/Codec2Loader.test.js b/tests/frontend/Codec2Loader.test.js
index 30e1f4dc..c6941dcc 100644
--- a/tests/frontend/Codec2Loader.test.js
+++ b/tests/frontend/Codec2Loader.test.js
@@ -52,6 +52,44 @@ describe("Codec2Loader integration (jsdom)", () => {
});
it("ensureCodec2ScriptsLoaded resolves when script tags load", async () => {
+ // Mock crypto.subtle.digest first
+ const mockHashBytes = new Uint8Array(48);
+ for (let i = 0; i < 48; i++) mockHashBytes[i] = i;
+ const mockHashB64 = btoa(String.fromCharCode(...mockHashBytes));
+
+ vi.stubGlobal("crypto", {
+ subtle: {
+ digest: vi.fn(async () => mockHashBytes.buffer),
+ },
+ });
+
+ // Mock fetch to return integrity.json and script content
+ const mockIntegrity = {
+ files: {
+ "c2enc.js": `sha384-${mockHashB64}`,
+ "c2dec.js": `sha384-${mockHashB64}`,
+ "sox.js": `sha384-${mockHashB64}`,
+ "codec2-lib.js": `sha384-${mockHashB64}`,
+ "wav-encoder.js": `sha384-${mockHashB64}`,
+ "codec2-microphone-recorder.js": `sha384-${mockHashB64}`,
+ },
+ };
+
+ vi.spyOn(globalThis, "fetch").mockImplementation((url) => {
+ if (url.includes("integrity.json")) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => mockIntegrity,
+ });
+ }
+ // Return mock script content for any script request
+ const content = "// mock script";
+ return Promise.resolve({
+ ok: true,
+ arrayBuffer: async () => new TextEncoder().encode(content).buffer,
+ });
+ });
+
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
if (node instanceof HTMLScriptElement && node.src) {
origAppend(node);
@@ -64,19 +102,23 @@ describe("Codec2Loader integration (jsdom)", () => {
await ensureCodec2ScriptsLoaded();
await expect(ensureCodec2ScriptsLoaded()).resolves.toBeUndefined();
expect(document.querySelectorAll("script[data-codec2-src]").length).toBe(6);
+
+ vi.unstubAllGlobals();
});
it("startCodec2ScriptsBackgroundLoad swallows errors after retries", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
- if (node instanceof HTMLScriptElement && node.src) {
- origAppend(node);
- queueMicrotask(() => node.dispatchEvent(new Event("error")));
- return node;
- }
- return origAppend(node);
+
+ // Mock crypto
+ vi.stubGlobal("crypto", {
+ subtle: {
+ digest: vi.fn(async () => new ArrayBuffer(48)),
+ },
});
+ // Mock fetch to fail (simulate network/SRI failure)
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
+
await startCodec2ScriptsBackgroundLoad({
maxAttempts: 2,
baseDelayMs: 0,
@@ -85,5 +127,6 @@ describe("Codec2Loader integration (jsdom)", () => {
expect(warn).toHaveBeenCalled();
warn.mockRestore();
+ vi.unstubAllGlobals();
});
});
diff --git a/tests/frontend/SriIntegrity.test.js b/tests/frontend/SriIntegrity.test.js
new file mode 100644
index 00000000..9fee5293
--- /dev/null
+++ b/tests/frontend/SriIntegrity.test.js
@@ -0,0 +1,145 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync, existsSync } from "fs";
+import { createHash } from "crypto";
+import path from "path";
+
+const REPO_ROOT = path.resolve(__dirname, "../..");
+
+function computeSri384(filePath) {
+ const buf = readFileSync(filePath);
+ const hash = createHash("sha384").update(buf).digest("base64");
+ return `sha384-${hash}`;
+}
+
+describe("SRI (Subresource Integrity) Verification", () => {
+ describe("micron-parser-go WASM", () => {
+ it("has valid integrity.json with matching SRI hashes", () => {
+ const integrityPath = path.join(
+ REPO_ROOT,
+ "meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json"
+ );
+ const wasmPath = path.join(
+ REPO_ROOT,
+ "meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm"
+ );
+ const execPath = path.join(REPO_ROOT, "meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js");
+
+ if (!existsSync(integrityPath)) {
+ return; // Skip if WASM not fetched
+ }
+
+ const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
+ expect(integrity.version).toBeTruthy();
+ expect(integrity.wasm).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+ expect(integrity.wasmExec).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+
+ if (existsSync(wasmPath)) {
+ const actualWasmHash = computeSri384(wasmPath);
+ expect(actualWasmHash).toBe(integrity.wasm);
+ }
+
+ if (existsSync(execPath)) {
+ const actualExecHash = computeSri384(execPath);
+ expect(actualExecHash).toBe(integrity.wasmExec);
+ }
+ });
+ });
+
+ describe("Codec2 Emscripten", () => {
+ it("has valid integrity.json with all required files", () => {
+ const integrityPath = path.join(
+ REPO_ROOT,
+ "meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json"
+ );
+ const baseDir = path.join(REPO_ROOT, "meshchatx/src/frontend/public/assets/js/codec2-emscripten");
+
+ expect(existsSync(integrityPath)).toBe(true);
+
+ const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
+ expect(integrity.version).toBeTruthy();
+ expect(integrity.files).toBeTruthy();
+
+ const requiredFiles = [
+ "c2dec.wasm",
+ "c2dec.js",
+ "c2enc.wasm",
+ "c2enc.js",
+ "sox.wasm",
+ "sox.js",
+ "codec2-lib.js",
+ "codec2-microphone-recorder.js",
+ "processor.js",
+ "wav-encoder.js",
+ ];
+
+ for (const file of requiredFiles) {
+ expect(integrity.files[file]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+
+ const filePath = path.join(baseDir, file);
+ if (existsSync(filePath)) {
+ const actualHash = computeSri384(filePath);
+ expect(actualHash).toBe(integrity.files[file]);
+ }
+ }
+ });
+ });
+
+ describe("RNode Flasher", () => {
+ it("has valid integrity.json with all required vendor libraries", () => {
+ const integrityPath = path.join(REPO_ROOT, "meshchatx/src/frontend/public/rnode-flasher/js/integrity.json");
+ const baseDir = path.join(REPO_ROOT, "meshchatx/src/frontend/public/rnode-flasher/js");
+
+ expect(existsSync(integrityPath)).toBe(true);
+
+ const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
+ expect(integrity.version).toBeTruthy();
+ expect(integrity.files).toBeTruthy();
+
+ const requiredFiles = [
+ "zip.min.js",
+ "crypto-js@3.9.1-1/core.js",
+ "crypto-js@3.9.1-1/md5.js",
+ "esptool-js@0.4.5/bundle.js",
+ "nrf52_dfu_flasher.js",
+ "rnode.js",
+ "web-serial-polyfill@1.0.15/dist/serial.js",
+ ];
+
+ for (const file of requiredFiles) {
+ expect(integrity.files[file]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+
+ const filePath = path.join(baseDir, file);
+ if (existsSync(filePath)) {
+ const actualHash = computeSri384(filePath);
+ expect(actualHash).toBe(integrity.files[file]);
+ }
+ }
+ });
+ });
+
+ describe("SRI hash format validation", () => {
+ it("all SRI hashes use sha384- prefix and base64 format", async () => {
+ const integrityFiles = [
+ "meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json",
+ "meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json",
+ "meshchatx/src/frontend/public/rnode-flasher/js/integrity.json",
+ ];
+
+ for (const relPath of integrityFiles) {
+ const fullPath = path.join(REPO_ROOT, relPath);
+ if (!existsSync(fullPath)) continue;
+
+ const data = JSON.parse(readFileSync(fullPath, "utf-8"));
+ const hashes =
+ data.wasm && data.wasmExec ? [data.wasm, data.wasmExec] : Object.values(data.files || {});
+
+ for (const hash of hashes) {
+ expect(hash).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+ // SHA-384 in base64 should be exactly 64 characters
+ const b64Part = hash.replace("sha384-", "");
+ expect(b64Part.length).toBe(64);
+ }
+ }
+ });
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────